Skip to content

node:net: finish an accepted socket whose native close leaves a write parked - #43698

Merged
Jarred-Sumner merged 3 commits into
mainfrom
robobun/c91a08d7/net-close-with-parked-write
Sep 21, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
robobun/c91a08d7/net-close-with-parked-write

Conversation

@robobun

@robobun robobun commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • An accepted node:tls socket whose write is the first to see the peer's RST emits 'end' and nothing else: no write callback, no 'error', no 'close'. The server counts it forever, so server.close() never completes. On main, 16 of 30 connections end this way. Node: 0 of 30.
  • The last block of SocketEmitEndNT (src/js/node/net.ts:875) fails a parked write only when the native close carries an error or the socket is destroyed. Here the failed send() consumed the socket error, so the close carries none.

Fix

  • A half-open 'end' runs the same function, and its write can still drain. Tell the two apart by kclosed, which only the native close handlers set. A parked write on a closed handle fails with ERR_SOCKET_CLOSED, as the client-side close handler already does. 'error' and 'close' follow.
  • tls: report a rejected send() as a write error instead of a clean close #42336 fixes the cause in the TLS write path, and alone it also removes this state (0 of 30, measured). tls: close the connection when a post-handshake SSL_write fails instead of leaving it open #38176 alone does not (18 and 26 of 30). This change is the JS-side guarantee for any clean close that leaves a write parked.
  • Verified: new test in test/js/node/tls/node-tls-server.test.ts (fails on main with events: [] and 2 connections, passes here). test/js/node/tls/, test/js/node/net/ and Node's 325 test-net-*/test-tls-* files: same failures as main.

Background

  • A write the kernel does not take whole is parked: the native socket buffers the rest, and net.ts keeps the callback in kwriteCallback until the drain.
  • Accepted sockets are half-open natively, so a peer FIN dispatches only 'end'.
  • After a native close the fd is gone, and nothing parked can drain.
Notes

The ledger's repro (30 connections, the peer resets on the first byte of a 1 MiB write, node peer, linux-x64)

build never closed the other connections
node v26.3.0 0 of 30 error:ECONNRESET, close:true
main (release 367d939) 16 of 30 (8 to 25 over other runs) error:ECONNRESET, close:true
main + #38176 18 and 26 of 30 same
main + #42336 0 and 0 of 30 cb:ECONNRESET, error:ECONNRESET, close:true
this branch 0 of 30 16 took end, cb:ERR_SOCKET_CLOSED, error:ERR_SOCKET_CLOSED, close:true

For the two PR rows I merged each PR's head into main @a2b69f7b. #38176 merges cleanly. #42336 conflicts in two test files only, which I resolved to main's side. src/ and packages/ merged without conflicts.

Mechanism, traced with the Socket debug scope

[socket] write(1048576) = 393216      the write takes many send() calls; one of them meets the RST
[socket] onEnd S                      recv() returns 0: the failed send() consumed the error
[socket] onClose S                    hangup, close code 0

The TLS write path folds a rejected send() to "wire blocked" and keeps no errno (that is #42336). A plain net.createServer does not reach this state, because us_socket_write_check_error reports the errno and failWrite fails the write.

What still differs from Node after this change

The sockets that took this path report 'end', then ERR_SOCKET_CLOSED. Node reports write ECONNRESET and no 'end'. The errno is gone by the time the close reaches JS, so only the native fix can restore it. With #42336 the write fails at write time and nothing is parked at the close, so the two changes do not overlap. If #42336 lands first, the new test here passes without this change, and this becomes a guard with no known trigger on Linux.

The test

The server runs in a child process so that it can stop polling. It reports the accepted socket, then blocks in fs.readSync(0) until the test has reset the connection, and only then writes. The write is therefore the first operation to see the reset on every run, with no timing involved:

node v26.3.0:  write()=false | cb:ECONNRESET | error:ECONNRESET | close:true   connections 0
main:          write()=false | end                                            (nothing more, 3 of 3)
this branch:   write()=false | end | cb:ERR_SOCKET_CLOSED | error:ERR_SOCKET_CLOSED | close:true   connections 0

A socket that never closes gives no event to wait for, so the server reports when a second connection arrives. That handshake takes several turns of the server's loop, and the reset socket closes in the first of them or not at all. The server then destroys both sockets, so the child exits in both outcomes and the test fails with the recorded state, not with a timeout (about 2 s on a debug build of main).

The test asserts what holds in Node and on every platform: 'error', then 'close' with hadError true, then a getConnections() of 1, which is the second connection. It also asserts that the child exited on its own. It does not pin the error code or the write callback. On main the read-error path never calls a parked write's callback, which is #43250. If the reset were seen by a read first, a fixed build would still pass.

Overlap with open PRs

#43392 rewrites the same block into a helper and keeps the same destroyed || _err condition, so it does not cover this case. The two conflict textually in that one hunk. The resolution is to keep its helper and call it when kclosed is set.


no test proof · iteration 4 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/tls/node-tls-server.test.ts

… parked

A send() that fails with the peer's reset consumes the socket error. The
loop then sees a plain hangup, so the native close carries no error. The
server-side close path failed a parked write only when the close had an
error or the socket was already destroyed, because the same function also
serves a half-open 'end', whose write can still drain. With neither, the
socket emitted 'end' and nothing else: no write callback, no 'error', no
'close', and the server counted it until the process exited.

Tell the two apart by kclosed, which only the native close handlers set.
A parked write on a closed handle now fails with ERR_SOCKET_CLOSED, as it
already does on the client side, and 'error' and 'close' follow.
@robobun

robobun commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (release 367d939 and a debug build of a2b69f7), linux-x64, with the ledger's one-file repro: a tls.createServer handler writes 1 MiB, and the peer resets the TCP connection on the first byte, 30 connections.

node v26.3.0:  error:ECONNRESET | close:true   30 of 30, getConnections 0
main:          write()=false | end             16 of 30 never close, getConnections 16
this branch:   every socket emits 'error' and 'close', getConnections 0

The write takes many send() calls. One of them meets the RST and consumes the socket error, so the loop then sees a plain hangup and the native close carries no error. The server-side close path in net.ts failed a parked write only when the close had an error, so the socket emitted 'end' and nothing else.

A deterministic form: the server blocks (stops polling) until the peer has reset, then writes. main ends with write()=false | end 3 of 3. Node ends with cb:ECONNRESET | error:ECONNRESET | close:true.

CI on ea13e645 (build 119268): the new test passes on every lane, Windows and macOS included, and no node:net or node:tls test fails. The build is red only for test/js/bun/s3/s3.test.ts, where large uploads to R2 time out after 15 s on five lanes. This branch does not touch that path, and the same two S3 files fail in every build of every branch between 16:00 and 17:30 UTC on 2026-09-21. Reported for main.

@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 49b0d017-b18a-4ed4-8f1b-b69d433704aa

📥 Commits

Reviewing files that changed from the base of the PR and between 9f37921 and 1740dfa.

📒 Files selected for processing (1)
  • test/js/node/tls/node-tls-server.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

The socket implementation now fails pending writes after closed or destroyed states, including errorless native closes. A TLS regression test verifies ordered error and close events after a peer reset during a buffered write.

Changes

Socket close handling

Layer / File(s) Summary
Pending-write close errors
src/js/node/net.ts, test/js/node/tls/node-tls-server.test.ts
Pending writes now complete with ERR_SOCKET_CLOSED when the socket is closed or destroyed. The TLS test checks error-before-close ordering, one remaining active connection, clean stderr, and successful process exit.

Suggested reviewers: cirospaciari

Priority: ⬇️ Low

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: completing accepted sockets when a native close leaves a write parked.
Description check ✅ Passed The description explains the problem, fix, background, verification steps, regression test, and scope. It does not use the exact template headings, but it provides the required information in equivale…

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js/node/net.ts — Callers that wait on a write callback still never hear back when the native close carries a read error and the socket has an 'error' listener, even after this PR. At net.ts:829-864 SocketEmitEndNT destroys the socket with the read error and returns before reaching the parked-write block at net.ts:879-882, so self[kwriteCallback] is left set and never invoked; Node delivers ECANCELED to that callback. The PR's stated goal is a JS-side guarantee for any close that leaves a write parked, but this sibling branch of the same function is excluded. Fix: fail the parked write (with the close error or ECANCELED) on every exit of SocketEmitEndNT that follows a native close, i.e. move the kwriteCallback block before the early return at net.ts:864 or into a helper both branches call.

    Extended reasoning...

    Trigger: an accepted socket (ServerHandlers.close, net.ts:1047-1051) or an fd-connected socket (SocketHandlers.close, net.ts:611-617) closes natively with a recv error such as ECONNRESET while a write is parked in self[kwriteCallback] (set at net.ts:2998), and the user has an 'error' listener. SocketEmitEndNT takes the branch at net.ts:829, calls self.destroy(er) at net.ts:845, 859 or 862 and returns at net.ts:864. The new parked-write block at net.ts:879-882 is never reached, so the write callback is never called and self[kwriteCallback] stays set on a destroyed socket. Node's onWriteComplete calls the write callback with errnoException(UV_ECANCELED, 'write') when the handle closes with a request in flight; any code that awaits the callback (a promisified write, a queue that advances on callback) stalls forever. The base branch behaves the same, and the PR scopes it to #43250, but the PR modifies exactly this function's parked-write handling and the repository's own review rule asks that sibling paths of the same class be fixed together. Population: every server socket whose reset is…

    Verification: pre-existing (the base branch fails by the exact same route; the PR does not widen it, but it does modify this function and its description mentions the gap as issue #43250 rather than closing it). Trigger: a native close that carries a read error (ECONNRESET from recv) arrives while a write is parked in self[kwriteCallback] and the socket has an 'error' listener. Mechanism verified in…

@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

On the review's extra finding (a parked write's callback is never called when the native close carries a read error and the socket has an 'error' listener): confirmed, and it is the same on main. I am leaving it out of this PR on purpose.

#43250 owns that path. It takes the parked callback in Socket.prototype._destroy, so it covers every destroy and not only this function, and it fails the callback the way Node does: write ECANCELED, on the next tick, after 'error' and before 'close'. Failing it here, before the early return, would hand the callback the read error instead of ECANCELED, would run it before 'error', and would conflict with #43250 in the same lines.

The case this PR closes is the one #43250 cannot reach: the close carries no error and nothing destroys the socket, so _destroy never runs. That is why the new test asserts 'error', 'close' and the connection count, and does not assert the write callback.

The test read stderr only after stdout ended, so a child that logs more
than the pipe holds would block on it and the test would hang instead of
failing on the stderr assertion.
Comment thread src/js/node/net.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I traced the three SocketEmitEndNT callers: both native close handlers (net.ts:614, :1048) set kclosed before the call and the end handler (:655) does not, so the half-open path keeps its write parked as before, and every call that passes _err also has kclosed set, so the existing error-carrying behavior is unchanged. The sibling client-side close handler (net.ts:1548) already fails a parked write unconditionally, so this brings the server path in line rather than adding a new class of behavior.

Extended reasoning...

The change is a one-line condition swap in the tail of SocketEmitEndNT in src/js/node/net.ts (fail a parked write on kclosed || destroyed instead of destroyed || _err) plus one new subprocess test in test/js/node/tls/node-tls-server.test.ts. It touches no security-sensitive surface. A confirmed finding on the test's unbounded stdout loop is posted inline; the socket lifecycle code it modifies is fragile enough that a human look is still worthwhile, which is why this is not an approve.

Comment thread test/js/node/tls/node-tls-server.test.ts
Without the fix the accepted socket never emits 'close', so the test had
no event to wait for and ran into its timeout. The server now reports
when a second connection arrives. That handshake takes several turns of
the server's loop, and the reset socket closes in the first of them or
not at all. The test then fails with the recorded events and the
connection count, and it checks that the child exited on its own.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the latest push and found no bugs; since this changes when node:net fails a parked write on every accepted-socket close and I could not execute the test here, a human look at the cross-platform CI result is still worthwhile.

What was reviewed:

  • The kclosed gate: it is set only in the three native close handlers, each right after detachSocket, so no drain can arrive afterward and a half-open 'end' (via SocketHandlers.end) never sees it set — it now matches what the client-side close handler at net.ts:1547 already does unconditionally.
  • The test's assertion holds whichever path observes the reset first: read-first goes through destroy(er) and write-first through the new branch, and both yield ['error', 'close:true'].
  • The latest commit addresses my earlier inline nit: the second connection bounds the wait and signalCode is asserted, so a regression now fails with the recorded events instead of timing out.
Extended reasoning...

The PR changes one condition in SocketEmitEndNT in src/js/node/net.ts so a parked write callback is failed with ERR_SOCKET_CLOSED on any native close (kclosed) rather than only when the close carries an error, and adds one subprocess test in test/js/node/tls/node-tls-server.test.ts. It touches no security-sensitive surface (no auth, crypto, or input parsing), only socket lifecycle ordering. The change is small and consistent with the sibling client-side close handler, and the author addressed the prior test-robustness nit in the last commit. Deferral rather than approval is because socket close/lifecycle in node:net has a history of platform-timing fallout across other suites, the PR's own evidence note says the test was not executed by the author locally, and I could not run a debug build or the system-Bun failure check in this environment.

@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

A note for whoever reads the CI result: the evidence block in the description reads as if nobody ran the test. I ran it both ways on linux-x64 with a debug (ASAN) build.

  • With main's src/js/node/net.ts it fails in 2.3 s with { events: [], connections: 2 } (58 ms on the 1.4.3 canary).
  • On this branch it passes 3 of 3 runs. The whole file has 78 pass and 1 fail. That one, SNICallback runs even when the requested servername matches the bind hostname, fails the same way on the release binary in my container, so this change does not cause it.
  • CI on the earlier head ea13e645 (build 119268) passed this test on every lane, Windows and macOS included. Build 119319 now runs the new shape of the test.

@Jarred-Sumner
Jarred-Sumner merged commit d34421a into main Sep 21, 2026
5 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/c91a08d7/net-close-with-parked-write branch September 21, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants